#!/usr/bin/env python
#
# Dynamic Desktop Fitter
# Sane fitting windows on changed monitor resolutions
#

import argparse
import os
import re
import subprocess
import sys
import types


class Client:


    _exp_clients = r"^(\w+)\s+([-\w]+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(.*)"
    re_wmctrl_clients = re.compile(_exp_clients)

    _wh2keep = {
        'w': (True, False),
        'h': (False, True),
        'wh': (True, True)}


    def __init__(self, line):
        m = self.__class__.re_wmctrl_clients.match(line)
        self.ok = m is not None
        if self.ok:
            gs = m.groups()
            self.id = gs[0]
            self.desktop = gs[1]
            self.xy = tuple(map(int, gs[2:4]))
            self.wh = tuple(map(int, gs[4:6]))
            self.class_and_name = gs[6]

    def __str__(self):
        return "{id=%s, D=%s, xy=%s, wh=%s '%s'}" % (
            self.id, self.desktop, "(%d,%d)" % self.xy, "(%d,%d)" % self.wh,
            self.class_and_name)


    def geo(self):
        return "xy=%s, wh=%s" % ("(%d,%d)" % self.xy, "(%d,%d)" % self.wh)


    def overflow_trim(self, offset, delta, limit):
        overflow = offset + delta - limit
        if overflow > 0:
            offset = max(0, offset - overflow)
        return offset


    def fit(self, app, previous_geo, new_geo, keep_wh):
        keep = self.__class__._wh2keep.get(keep_wh, (False, False))
        xy_new = [None, None]
        wh_new = [None, None]
        for i in (0, 1):
            xyi = self.xy[i]
            whi = self.wh[i]
            xyi = self.overflow_trim(xyi, whi, previous_geo[i])
            if keep[i]:
                old_residue = max(previous_geo[i] - whi, 1)
                new_residue = new_geo[i] - whi
                xy_new[i] = max(0, (xyi * new_residue) / old_residue)
                wh_new[i] = whi
            else:
                oldg = max(previous_geo[i], 1)
                newg = new_geo[i]
                xy_new[i] = (xyi * newg) / oldg
                wh_new[i] = (whi * newg) / oldg
                xy_new[i] = self.overflow_trim(xy_new[i], wh_new[i], newg)
        cmd = "wmctrl -i -r %s -e 0,%d,%d,%d,%d" % (
            self.id, xy_new[0], xy_new[1], wh_new[0], wh_new[1])
        app.log("%s  # %s" % (cmd, self.klass()))
        if not app.args_parsed.dry:
            rc = os.system(cmd)
            if rc != 0:
                app.log("Failed: rc=0x%x" % rc)


    def klass(self):
        return self.class_and_name.split()[0]
                


class DFit:

    # _exp_desktops = r"^(\w+)\s+.*DG:\s*(\d+)x(\d+)"
    _exp_desktops = r"^(\w+)\s+.*WA:\s.*\s+(\d+)x(\d+)"
    re_wmctrl_desktops = re.compile(_exp_desktops)

    def __init__(self, argv):
        self.rc = 0
        self.argv = argv
        self.args_parsed = self.parse_args()
        self.log("args_parsed: %s" % str(self.args_parsed))


    def parse_args(self):
        default_cfg = "%s/.dfit" % os.getenv("HOME")
        self.parser = p = argparse.ArgumentParser(description="dfit args")
        p.add_argument("-i", "--iconf", dest="config_fn", type=str,
                       default=default_cfg,
                     help="configuration file, default: %(default)s")
        p.add_argument("-p", "--pre-geometry", dest="previous_geometry",
                       nargs=2, type=int,
                       metavar=("W", "H"),
                       help="Previous geometry to fit from, " +
                       "default: nearest to bounding box of clients")
        p.add_argument("-g", "--geometry", dest="geometry", nargs=2, type=int,
                       metavar=("W", "H"),
                       help="Geometry to fit into, " +
                       "default: current X11-dimension")
        p.add_argument("-d", "--daemon", dest="daemon", action='store_true')
        p.add_argument("-n", "--dry-run", dest="dry", action='store_true')
        return p.parse_args()
        # (self.options, self.filenames) = p.parse_args(self.argv[1:])
        # self.log("nfiles=%d" % len(self.filenames))


    def run(self):
        self.log("run")
        self.config_load()
        if self.ok():
            self.fit()


    def config_load(self):
        fn = self.args_parsed.config_fn
        self.log("config_load fn=%s" % fn)
        try:
            self.config = eval(open(fn).read())
        except Exception, why:
            self.error("Failed to parse %s, %s" % (fn, why))
        if self.ok():
            self.config_check()


    def config_check(self):
        self.log("config_check: %s" % str(self.config))
        if not isinstance(self.config, types.DictType):
            self.error("Configuration is not a dictionary")
        if self.ok():
            self.config_check_sizes()
        if self.ok():
            self.config_check_keep()


    def config_check_sizes(self):
        try:
            sizes = self.config.get("sizes", None)
        except Exception, why:
            self.error("Configuration: Missing sizes, %s" % why)
            sizes = None
        if sizes is None:
            self.error("Configuration: Missing sizes")
        # elif type(sizes) != type([]):
        elif not isinstance(sizes, types.ListType):
            self.error("Configuration: 'sizes' is not a list")
        elif len(sizes) < 2:
            self.error("Configuration: 'sizes' length is less than 2")
        else:
            i = 0
            while self.ok() and i < len(sizes):
                wh = sizes[i]
                if not (isinstance(wh, types.TupleType) and
                        (len(wh) == 2) and
                        isinstance(wh[0], int) and isinstance(wh[1], int)):
                    self.error("Bad sizes[%d]=%s" % (i, str(wh)))
                i += 1


    def config_check_keep(self):
        keep = self.config.get("keep", {})
        accapted_values = ('w', 'h', 'wh')
        for kv in keep.items():
            # self.log("config_check_keep: checking %s" % str(kv))
            if not isinstance(kv[0], types.StringType):
                self.error("Keep key is not a string: %s" % str(kv[0]))
            elif not kv[1] in accapted_values:
                self.error("Keep value %s is not accepted (%s)" %
                           (str(kv[1]), accapted_values))


    def fit(self):
        self.log("fit")
        self.target_geometry_set()
        self.clients_get()
        self.previous_geometry_get()
        if self.ok():
            self.clients_fit()


    def target_geometry_set(self):
        self.log("target_geometry_set")
        target_geometry = self.args_parsed.geometry
        if target_geometry is None:
            cmd = "wmctrl -l -d"
            p = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE)
            rc = p.wait()
            if self.rc != 0:
                self.error("Command '%s' failed, rc=0x%x" % (cmd, rc))
            else:
                self.clients = []
                outlines = p.stdout.readlines()
                d2g = {}
                for line in outlines:
                    m = self.__class__.re_wmctrl_desktops.match(line)
                    if m is None:
                        self.log("Ignore '%s' output: %s" % (cmd, line))
                    else:
                        gs = m.groups(0)
                        desktop = gs[0]
                        wh = tuple(map(int, gs[1:3]))
                        self.log("Desktop[%s] = wh(%d, %d)" %
                                 (desktop, wh[0], wh[1]))
                        d2g[desktop] = wh
                self.desktop2geometry = d2g


    def clients_get(self):
        self.log("clients_get")
        cmd = "wmctrl -l -G -x"
        p = subprocess.Popen(cmd.split(), stdout=subprocess.PIPE)
        rc = p.wait()
        if self.rc != 0:
            self.error("Command '%s' failed, rc=0x%x" % (cmd, rc))
        else:
            self.clients = []
            outlines = p.stdout.readlines()
            for line in outlines:
                c = Client(line)
                if c.ok:
                    self.clients.append(c)
                else:
                    self.log("Ignored client: %s" % line[:-1])
            self.log("Got %d clients" % len(self.clients))



    def clients_fit(self):
        self.log("clients_fit")
        keep_dict = self.config.get("keep", {})
        for client in self.clients:
            target_geometry = self.client_target_geomery_get(client)
            if target_geometry is None:
                self.log("Skipping client: id=%s, %s" %
                         (client.id, client.class_and_name))
            else:
                cn = client.class_and_name
                keepval = None
                for key,val in keep_dict.items():
                    if keepval is None and cn.find(key):
                        keepval = val
                new_geometry = self.client_target_geomery_get(client)
                self.log("%s -> %s [%s] %s" %
                         (str(self.previous_geometry), str(new_geometry),
                          client.klass(), client.geo()))
                client.fit(self, self.previous_geometry, new_geometry, keepval)
                
            

    def client_target_geomery_get(self, client):
        target_geometry = self.args_parsed.geometry
        if target_geometry is None:
            dt = client.desktop
            if dt == '-1':
                dt = '0'
            target_geometry = self.desktop2geometry.get(dt, None)
            if target_geometry is None:
                self.log("Geometry none for desktop=%s" % dt)
        return target_geometry


    def previous_geometry_get(self):
        self.log("previous_geometry_get")
        self.previous_geometry = self.args_parsed.previous_geometry
        if self.previous_geometry is None:
            self.previous_geometry_by_bounding_box()


    def previous_geometry_by_bounding_box(self):
        self.log("previous_geometry_by_bounding_box")
        d2og = {}
        # Compute bounding box of clients
        right_max = bottom_max = 0
        for client in self.clients:
            cright = client.xy[0] + client.wh[0]
            cbottom = client.xy[1] + client.wh[1]
            if cbottom > 1024:
                self.log("cbottom=%d, cn=%s" % (cbottom, client.class_and_name))
            right_max = max(right_max, cright)
            bottom_max = max(bottom_max, cbottom)
            # self.log("ogs: cright=%d, cbottom=%d" % (cright, cbottom))
        bb = (right_max, bottom_max)


        # Find the nearset known size
        nearest_geo = None
        sizes = self.config["sizes"]
        mindist = sys.maxint
        for size in sizes:
            d = abs(size[0]-bb[0]) + abs(size[1]-bb[1])
            if d < mindist:
                d = mindist
                nearest_geo = size
        self.previous_geometry = nearest_geo
        self.log("bb=%s, previous_geometry=%s" %
                 (str(bb), str(self.previous_geometry)))
        
        if False:
         # Now fit in knwon sizes
         sizes = self.config["sizes"]
         si = 0
         while si < len(sizes):
             size = sizes[si]
             if bb[0] <= size[0] and bb[1] <= size[1]:
                 bb = size
                 si = len(sizes) # Exit loop
             si += 1
         self.log("previous_geometry: bb=(%d,%d)" % bb)
         self.previous_geometry = bb
        


    def old_geometry_get_UNUSED(self):
        self.log("old_geometry_get")
        d2og = {}

        # Compute bounding box of clients per each desktop
        for client in self.clients:
            dt = client.desktop
            wh_curr = d2og.get(dt, (0, 0))
            wh = (max(wh_curr[0], wh[0]), max(wh_curr[1], wh[1]))
            d2og[dt] = wh

        # Fit in first possible known size
        

    def ok(self):
        return self.rc == 0


    def mayrun(self):
        return self.ok()


    def syscmd(self, cmd):
        self.log(cmd)
        rc = os.system(cmd)
        if rc != 0:
            self.error("Failed, rc=%d=0x%x cmd: %s" % (rc, rc, cmd))


    def error(self, msg):
        if self.ok():
            self.log("ERROR: %s\n" % msg)
            self.parser.print_help(sys.stderr)
            self.rc = 1


    def log(self, msg):
        sys.stderr.write("%s\n" % msg)

    
if __name__ == "__main__":

    p = DFit(sys.argv)
    if p.mayrun():
        p.run()
    sys.exit(p.rc)
